查看原文
其他

面试官:子线程 真的不能更新UI ?

胡飞洋 胡飞洋 2022-07-18

我们从一个异常说起:

1android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.
2        at android.view.ViewRootImpl.checkThread(ViewRootImpl.java:8820)
3        at android.view.ViewRootImpl.requestLayout(ViewRootImpl.java:1530)
4        at android.view.View.requestLayout(View.java:24648)
5        at android.widget.TextView.checkForRelayout(TextView.java:9752)
6        at android.widget.TextView.setText(TextView.java:6326)
7        at android.widget.TextView.setText(TextView.java:6154)
8        at android.widget.TextView.setText(TextView.java:6106)
9        at com.hfy.demo01.MainActivity$9.run(MainActivity.java:414)
10        at android.os.Handler.handleCallback(Handler.java:888)
11        at android.os.Handler.dispatchMessage(Handler.java:100)
12        at android.os.Looper.loop(Looper.java:213)
13        at android.app.ActivityThread.main(ActivityThread.java:8147)
14        at java.lang.reflect.Method.invoke(Native Method)
15        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:513)
16        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1101)

一般情况,我们在子线程直接操作UI,没有用handler切到主线程,就会报这个错。

那如果我说,我这里的这个错误就发生在 主线程,你信吗?

下面是具体代码,handleAddWindow()按在MainActivity 的onCreate中执行。

1    private void handleAddWindow() {
2
3        //子线程创建window,只能由这个子线程访问 window的view
4        Button button = new Button(MainActivity.this);
5        button.setText("添加到window中的button");
6        button.setOnClickListener(new View.OnClickListener() {
7            @Override
8            public void onClick(View view) {
9                MyToast.showMsg(MainActivity.this"点了button");
10            }
11        });
12
13        new Thread(new Runnable() {
14            @Override
15            public void run() {
16                //因为添加window是IPC操作,回调回来时,需要handler切换线程,所以需要Looper
17                Looper.prepare();
18
19                addWindow(button);
20
21                new Handler().postDelayed(new Runnable() {
22                    @Override
23                    public void run() {
24                        button.setText("文字变了!!!");
25                    }
26                },3000);
27
28                //开启looper,循环取消息。
29                Looper.loop();
30            }
31        }).start();
32
33        //这里执行就会报错:Only the original thread that created a view hierarchy can touch its views.
34        new Handler().postDelayed(new Runnable() {
35            @Override
36            public void run() {
37                button.setText("文字 you 变了!!!");
38            }
39        },4000);
40    }
41
42    private void addWindow(Button view) {
43        WindowManager.LayoutParams layoutParams = new WindowManager.LayoutParams(
44                WindowManager.LayoutParams.WRAP_CONTENT,
45                WindowManager.LayoutParams.WRAP_CONTENT,
46                00,
47                PixelFormat.TRANSPARENT
48        );
49        // flag 设置 Window 属性
50        layoutParams.flags= WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL;
51        // type 设置 Window 类别(层级)
52        layoutParams.type = WindowManager.LayoutParams.TYPE_SYSTEM_OVERLAY;
53        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
54            layoutParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
55        }
56
57        layoutParams.gravity = Gravity.TOP | Gravity.LEFT;
58        layoutParams.x = 100;
59        layoutParams.y = 100;
60
61        WindowManager windowManager = getWindowManager();
62        windowManager.addView(view, layoutParams);
63    }

主要是:开了个子线程,然后添加了一个系统window,window中只有一个button。然后3秒后在子线程中直接改变Button的文字,然后又过一秒,在主线程中再改变button文字。

(其中涉及知识有handler、window。可点击“阅读原文”查看相关文章)

执行效果如下,可见 打开App后,左上角的Button,3秒后变了,接着一秒后crash了。

在这里插入图片描述

那为啥 子线程更新UI没报错,主线程报错呢?

首先,我们看报错原因的描述:Only the original thread that created a view hierarchy can touch its views. 翻译就是说 只有创建了view树的线程,才能访问它的子view。并没有说子线程一定不能访问UI。那可以猜想到,button的确实是在子线程被添加到window中的,子线程确实可以直接访问,而主线程访问确实会抛出异常。看来可以解释这个错误的原因了。
下面就具体分析下。

错误的发生在ViewRootImpl的checkThread方法中,且UI的更新都会走到这个方法:

1    void checkThread() {
2        if (mThread != Thread.currentThread()) {
3            throw new CalledFromWrongThreadException(
4                    "Only the original thread that created a view hierarchy can touch its views.");
5        }
6    }

(ViewRootImpl相关知识可点击“阅读原文”查看相关文章)

通过window的相关知识,我们知道,调用windowManager.addView添加window时会给这个window创建一个ViewRootImpl实例:

1    public void addView(View view, ViewGroup.LayoutParams params,
2            Display display, Window parentWindow) 
{
3        ...
4
5            ViewRootImpl root;
6            View panelParentView = null;
7        ...
8            root = new ViewRootImpl(view.getContext(), display);
9            view.setLayoutParams(wparams);
10            mViews.add(view);
11            mRoots.add(root);
12            mParams.add(wparams);
13...
14        }
15    }

然后ViewRootImpl构造方法中会拿到当前的线程,

1    public ViewRootImpl(Context context, Display display) {
2        mContext = context;
3        ...
4        mThread = Thread.currentThread();
5        ...
6    }

所以在ViewRootImpl的checkThread()中,确实是 拿 当前想要更新UI的线程 和 添加window时的线程作比较,不是同一个线程机会报错。

通过window的相关知识,我们还知道,Activity也是一个window,window的添加是在ActivityThread的handleResumeActivity()。ActivityThread就是主线程,所以Activity的view访问只能在主线程中

一般情况,UI就是指Activity的view,这也是我们通常称主线程为UI线程的原因,其实严谨叫法应该是activity的UI线程。而我们这个例子中,这个子线程也可以称为button的UI线程。

那为啥要一定需要checkThread呢?根据handler的相关知识:

因为UI控件不是线程安全的。那为啥不加锁呢?一是加锁会让UI访问变得复杂;二是加锁会降低UI访问效率,会阻塞一些线程访问UI。所以干脆使用单线程模型处理UI操作,使用时用Handler切换即可。

我们再看一个问题,Toast可以在子线程show吗答案是可以的

1        new Thread(new Runnable() {
2            @Override
3            public void run() {
4                //因为添加window是IPC操作,回调回来时,需要handler切换线程,所以需要Looper
5                Looper.prepare();
6
7                addWindow(button);
8
9                new Handler().postDelayed(new Runnable() {
10                    @Override
11                    public void run() {
12                        button.setText("文字变了!!!");
13                    }
14                },3000);
15
16                Toast.makeText(MainActivity.this"子线程showToast", Toast.LENGTH_SHORT).show();
17
18                //开启looper,循环取消息。
19                Looper.loop();
20            }
21        }).start();

在上面的例子,线程中showToast,运行发现确实可以的。因为根据window的相关知识,知道Toast也是window,show的过程就是添加Window的过程。

另外注意1,这个线程中Looper.prepare()和Looper.loop(),这是必要的
因为添加window的过程是和WindowManagerService进行IPC的过程,IPC回来时是执行在binder线程池的,而ViewRootImpl中是默认有Handler实例的,这个handler就是用来切换binder线程池的消息到当前线程。另外Toast还与NotificationMamagerService进行IPC,也是需要Handler实例。既然需要handler,那所以线程是需要looper的。另另外Activity还与ActivityManagerService进行IPC交互,而主线程是默认有Looper的。
扩展开,想在子线程show Toast、Dialog、popupWindow、自定义window,只要在前后调Looper.prepare()和Looper.loop()即可。

另外注意2,在activity的onCreate到首次onResume的时期,创建子线程在其中更新UI也是可以的。这不是违背上面的结论了吗?其实没有,上面说了,因为Activity的window添加在首次onResume之后执行的的,那ViewRootImpl的创建也是在这之后,所以也就无法checkThread了。实际上这个时期也不checkThread,因为View根本还没有显示出来。

1onCreate()中执行是OK的:
2
3new Thread(new Runnable() {
4    @Override
5    public void run() {
6        tv.setText("text");
7    }
8}).start();


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存